Z Algorithm
쉽게 설명하는 알고리즘 시리즈
1. Z Algorithm
2. Manacher’s Algorithm
3. Gosper’s Hack Algorithm


쉽게 설명하는 알고리즘 시리즈, 대망의 1편입니다.

다양한 알고리즘들을 직접 공부하고 하나씩 설명하는 방식으로 이어갈 예정입니다. 계속 연재할지 중간에 슬쩍 사라질지는 저도 모르겠지만.. 아무튼 열심히 해 보겠습니다.

첫 번째 알고리즘은 Z 알고리즘입니다.


Z 알고리즘이란?

Z Algorithm Explain Image Z Array

Z 알고리즘이란 문자열에서 특정 패턴을 찾는 패턴 검색 알고리즘입니다. O(M + N)의 선형 시간 복잡도를 가집니다.

또한 Z 알고리즘은 현재 인덱스, 즉 String[i]에서 시작하는 가장 긴 접두사 부분 문자열의 길이를 저장하는 배열인 *Z 배열이라는 보조 배열과 함께 작동합니다.

접두사 부분 문자열: 문자열의 첫 부분과 일치하는 현재 인덱스부터 시작하는 부분 문자열

Index            0   1   2   3   4   5   6 
Text             a   a   b   c   a   a   b
Z values         N   1   0   0   3   1   0

예를 들어, “aabcaab”라는 문자열에서, 5번째 a의 가장 긴 접두사 부분 문자열은 aab입니다. aabcaab가 시작 부분과 일치하기 때문입니다.

그렇기 때문에 만약 Z[i] = k라면, String[i .. i + k] = String[0 .. k]가 성립합니다. Z 알고리즘은 이 원리를 이용해 선형 시간으로 패턴을 탐색합니다.

쉽게 생각하자면, 패턴과 텍스트를 연결하여 "P$T" 문자열을 생성한다고 생각하면 됩니다. 여기서 PT는 접두사, $는 접두사에 포함되지 않는 문자입니다.

만약 Z 배열에서 Z[i]가 임의의 지점에서 접두사 길이와 같으면, 위의 원리에 따라 해당 지점은 접두사 부분 문자열이 존재한다고 볼 수 있습니다.

개념을 이해했으니, 실제 코드를 보며 이해해 봅시다.


구현

fun getZArray(s: String): IntArray {
    val n = s.length
    
    // 문자열 크기의 Z 배열을 사용합니다.
    val zArray = IntArray(n)
    
    // 선형 시간으로 구성하기 위해 두 개의 포인터 Left와 Right로 접두사 부분 문자열의 간격을 나타냅니다.
    var left = 0
    var right = 0
   
    // 0번째 인덱스는 문자열 전체가 접두사이기 때문에 탐색은 1부터 시작합니다.
    for (i in 1 until n) {
    
        // i가 Right 포인터보다 크다면, i는 두 포인터 사이의 간격(즉, 접두사 부분 문자열) 밖에 있습니다.
        // 그렇기 때문에 i 이전에 시작하고 i 이후에 끝나는 접두사 부분 문자열이 없습니다. 즉, 현재의 두 포인터 사이의 간격에서 얻을 수 있는 정보가 없습니다.
        if (i > right) {
        
            // 현재의 포인터 값들은 쓸모가 없기 때문에, Left와 Right를 i로 초기화합니다.
            left = i
            right = i

            // i만큼의 간격을 유지하며 Left와 Right가 가리키는 값이 다를 때까지 (즉, 더 이상 접두사 부분 문자열이 아닐 때까지) 간격을 확장합니다.
            while (right < n && s[right - left] == s[right]) {
                right++
            }

            // Z[i]를 두 포인터 사이의 간격(접두사 부분 문자열의 길이)으로 설정합니다.
            zArray[i] = right-- - left
        } else {
            // i가 Right 포인터보다 작거나 같다면, 여기부터는 두 가지 경우가 있습니다.
            val k = i - left

            // 만약 k부터 시작하는 접두사 부분 문자열의 길이가 현재 간격보다 짧다면, 확장할 수 있는 최대 간격이 현재 간격보다 짧다는 것을 의미합니다.
            // 따라서 i로부터 시작하는 접두사 부분 문자열의 길이는 최대 k이기 때문에  Z[i] = Z[k]로 설정하고, 간격은 동일하게 유지한 채로 다음 반복으로 넘어갑니다.
            if (zArray[k] < right - i + 1) {
                zArray[i] = zArray[k]
            } else {
            // 현재 간격보다 크다면, 현재 간격을 더 확장할 수 있다는 것을 의미합니다. 따라서 위의 코드처럼 간격을 확장하고 Z[i]를 설정합니다.
                left = i

                while (right < n && s[right - left] == s[right]) {
                    right++
                }

                zArray[i] = right-- - left
            }
        }
    }

    return zArray
}

코드 예제는 GitHub에서 확인하실 수 있습니다.


연습 문제

LeetCode 28. Find the Index of the First Occurrence in a String

텍스트 내에서 특정 패턴이 처음으로 등장하는 인덱스를 찾는 문제입니다. Z 알고리즘을 연습하기 좋은 입문 문제입니다.

LeetCode 2223. Sum of Scores of Built-in Strings

모든 공통 접두사의 길이의 합을 구하는 문제입니다. Hard 난이도지만 Z 알고리즘을 이해한다면 매우 쉽게 해결할 수 있습니다.